Skip to content

feat(rpc): add debug_intermediateRoots#11524

Open
0xDevNinja wants to merge 9 commits into
NethermindEth:masterfrom
0xDevNinja:feature/3456-debug-intermediate-roots
Open

feat(rpc): add debug_intermediateRoots#11524
0xDevNinja wants to merge 9 commits into
NethermindEth:masterfrom
0xDevNinja:feature/3456-debug-intermediate-roots

Conversation

@0xDevNinja
Copy link
Copy Markdown

Fixes #3456

Changes

  • Add IntermediateRootsBlockTracer (Nethermind.Blockchain.Tracing) — captures the world state root after each transaction. Falls back to IWorldState.RecalculateStateRoot() post-Byzantium where TransactionProcessor no longer pre-computes the root.
  • Add IGethStyleTracer.TraceBlockIntermediateRoots(blockHash, options, ct) and the matching GethStyleTracer implementation, mirroring the existing TraceBlock pipeline (parent header, scoped world state, BlockchainProcessor.Process).
  • Add IDebugBridge.GetBlockIntermediateRoots plumbing.
  • Expose debug_intermediateRoots(Hash256 blockHash, GethTraceOptions? options = null) on IDebugRpcModule / DebugRpcModule. Behaviour matches geth (eth/tracers: implement debug.intermediateRoots ethereum/go-ethereum#23594): one root per transaction, in execution order.
  • Tests in DebugModuleTests cover the success path and the no-state-available failure path.

Types of changes

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other: Description

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

Adds the debug_intermediateRoots JSON-RPC method, which replays a block and returns the world state root after each transaction in execution order. Matches the equivalent geth endpoint.

@svlachakis
Copy link
Copy Markdown
Contributor

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 8, 2026

Claude finished @svlachakis's task in 3m 28s —— View job


PR Review: feat(rpc): add debug_intermediateRoots

  • Gather context and load rules
  • Read changed files
  • Analyze correctness, security, and performance
  • Post findings

Summary

Clean, well-scoped implementation that follows the existing TraceBlock* pipeline faithfully. The layering (tracer → IGethStyleTracerIDebugBridgeIDebugRpcModule) is correct and consistent with the rest of the debug module. No correctness bugs or security issues found. A few code-quality gaps need addressing before merge.


Findings

Medium

Missing integration test for the tracer path (DebugModuleTests.cs, inline comment)

Both new tests mock IDebugBridge at the bridge boundary. The IntermediateRootsBlockTracer.Capture logic — specifically the worldState.RecalculateStateRoot() fallback for post-Byzantium blocks — is never exercised. Because RecalculateStateRoot() is called mid-block-processing (inside MarkAsSuccess/MarkAsFailed), any side-effects or implementation regressions in IWorldState would be invisible to the current test suite.

A TestBlockchain-based integration test (like those in GethStyleTracerTests) that replays a real two-transaction block and asserts the two returned roots are distinct and non-zero would close this gap.

Low

  1. Missing try/catch + TryDispose() in TraceBlockIntermediateRoots (GethStyleTracer.cs, inline comment) — Every other tracing path in the file wraps Process in try/catch. IntermediateRootsBlockTracer is not currently IDisposable so there's no leak today, but the pattern exists to make call sites resilient to future changes.

  2. Missing XML doc on IGethStyleTracer.TraceBlockIntermediateRoots (IGethStyleTracer.cs, inline comment) — Coding style requires <summary> on all public APIs.

  3. Missing "header not found" test for debug_intermediateRoots (DebugModuleTests.cs, inline comment) — Analogous to StandardTraceBlockToFile_returns_error_when_missing_block. The code path (null return from blockFinder.FindHeader) is not exercised for this method.

  4. GethTraceOptions.BlockOverrides silently ignoredBlockOverrides are applied in TraceImpl via options.BlockOverrides?.ApplyOverrides(block.Header) but not in TraceBlockIntermediateRoots. This is semantically correct (replaying an existing historical block shouldn't allow header overrides), but callers who pass BlockOverrides get no error. A note in the XML doc or a JsonRpcMethod description would help.


What looks good

  • Correctness of Capture: Calling worldState.RecalculateStateRoot() post-Byzantium is the right approach (mirrors geth's state.IntermediateRoot). The pre-Byzantium path (using the stateRoot argument from the receipt) is also correct.
  • Empty block: BlockTracerBase<TTrace, TTracer>.BuildResult() returns an empty collection when no transactions ran — correct.
  • State/header validation: TryGetHeaderAndCheckState is called before bridge dispatch, so pruned-state blocks are rejected cleanly with ResourceUnavailable.
  • Timeout: BuildTimeoutCancellationTokenSource() is wired up, keeping parity with other debug methods.
  • IsSharable = false: Correct — block re-execution is not shareable across concurrent requests.

IntermediateRootsBlockTracer tracer = new(scope.Component.WorldState);
scope.Component.BlockchainProcessor.Process(block, ProcessingOptions.Trace, tracer.WithCancellation(cancellationToken), cancellationToken);

return tracer.BuildResult();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — inconsistent exception-safety pattern

All other block processing paths in GethStyleTracer guard the tracer with a try/catch + tracer.TryDispose() call on exception (see TraceBlockImpl, Trace(long, Transaction, ...), etc.). IntermediateRootsBlockTracer is not currently IDisposable, so this doesn't leak today — but the pattern exists precisely so that tracers can become disposable without requiring every call site to be updated.

Suggested fix:

IntermediateRootsBlockTracer tracer = new(scope.Component.WorldState);
try
{
    scope.Component.BlockchainProcessor.Process(block, ProcessingOptions.Trace, tracer.WithCancellation(cancellationToken), cancellationToken);
    return tracer.BuildResult();
}
catch
{
    tracer.TryDispose();
    throw;
}

Fix this →

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point on the consistency — wrapped it in a160e9f1. If this tracer grows into being disposable later, the call site won't need to be remembered separately.

IReadOnlyCollection<GethLikeTxTrace> TraceBlock(BlockParameter blockParameter, GethTraceOptions options, CancellationToken cancellationToken);
IReadOnlyCollection<GethLikeTxTrace> TraceBlock(Rlp blockRlp, GethTraceOptions options, CancellationToken cancellationToken);
IReadOnlyCollection<GethLikeTxTrace> TraceBlock(Block block, GethTraceOptions options, CancellationToken cancellationToken);
IReadOnlyCollection<Hash256> TraceBlockIntermediateRoots(Hash256 blockHash, GethTraceOptions options, CancellationToken cancellationToken);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — missing XML doc

Per the project's coding style, all public API members should have <summary> documentation. Every other method in this interface currently lacks docs too, but since this is new surface area it's a good opportunity to set the standard.

/// <summary>Replays <paramref name="blockHash"/> and returns the world-state root after each transaction, in execution order.</summary>
IReadOnlyCollection<Hash256> TraceBlockIntermediateRoots(Hash256 blockHash, GethTraceOptions options, CancellationToken cancellationToken);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added on the interface in 598cf9b1, plus a more descriptive JsonRpcMethod attribute alongside it.


actual.Result.ResultType.Should().Be(ResultType.Failure);
actual.ErrorCode.Should().Be(ErrorCodes.ResourceUnavailable);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — missing integration test + Low — missing "block not found" RPC test

Two gaps in coverage:

  1. Integration path: Both existing tests mock IDebugBridge entirely. The IntermediateRootsBlockTracer.Capture logic — particularly the worldState.RecalculateStateRoot() fallback for post-Byzantium blocks — is never exercised. Adding a test that drives TraceBlockIntermediateRoots through TestBlockchain (as other tracer tests do) would give confidence that the root accumulation across transactions is correct.

  2. Header-not-found path: StandardTraceBlockToFile_returns_error_when_missing_block exists for debug_standardTraceBlockToFile but the equivalent test is absent for debug_intermediateRoots. Consider adding:

[Test]
public void Debug_intermediateRoots_fails_when_block_not_found()
{
    Hash256 blockHash = TestItem.KeccakA;
    _blockFinder.FindHeader(blockHash).ReturnsNull();

    DebugRpcModule rpcModule = CreateDebugRpcModule(_debugBridge);
    ResultWrapper<IReadOnlyCollection<Hash256>> actual = rpcModule.debug_intermediateRoots(blockHash);

    actual.Result.ResultType.Should().Be(ResultType.Failure);
    actual.ErrorCode.Should().Be(ErrorCodes.ResourceNotFound);
}

Fix this →

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Block-not-found unit test landed in 8258b4fa. For the integration path, wired up a TestRpcBlockchain-driven test in 74b08eb2 that replays a real two-tx block and checks the roots are non-zero and distinct — and writing that test actually surfaced a real bug in the post-Byzantium fallback (details in the summary comment, fix in b3290a83). Glad it was flagged.

@LukaszRozmej
Copy link
Copy Markdown
Member

please fix claude marked issues

@svlachakis
Copy link
Copy Markdown
Contributor

@claude please review also the following findings.

  1. Blocking — correctness

GethStyleTracer.cs · TraceBlockIntermediateRoots — Genesis NRE. FindParent(block) returns null for genesis (GethStyleTracer.cs:224) and is passed straight into
BuildAndOverride. Geth returns "genesis is not traceable". Add an if (block.IsGenesis) throw … before the scope.

DebugRpcModule.cs · debug_intermediateRoots catch — InvalidOperationException is mapped to ResourceNotFound, but it's raised for "block not found", "parent hash
null" (GethStyleTracer.cs:229), and (post-fix) "genesis not traceable" — different conditions, different right answers. Either don't catch (matches TraceBlock),
or throw typed exceptions (BlockNotFoundException) and map those.

  1. Compat gaps vs geth

GethStyleTracer.cs · bad-block fallback — geth's debug_intermediateRoots (#23594) was designed for diagnosing client divergence on bad blocks; it falls back to
rawdb.ReadBadBlock. This PR only hits canonical via blockTree.FindBlock. Wire the bad-block store or open a follow-up.

IntermediateRootsBlockTracer.cs · partial-result semantics — geth returns roots[0..i] with nil error on mid-block tx failure (the diagnostic use case). Behavior
here is unspecified and untested. Pick a semantic and add a test.

  1. Tests

DebugModuleTests.cs — both new tests just verify the bridge is called. The "matches geth" claim hinges on these, none of which are covered:

  • N txs in → N roots out
  • Beacon root / parent-block-hash system calls do not add an entry
  • Withdrawals do not add an entry
  • Genesis hash → clean error
  • Block-not-found → ResourceNotFound
  • Roots equal post-tx state roots against a hand-executed reference block
  • Cancellation produces correct error
  1. Nits

IntermediateRootsBlockTracer.cs:7 — unused using Nethermind.Evm.TransactionProcessing;.

IntermediateRootsBlockTracer.cs · StateRoot = Keccak.Zero — dead initializer; Capture always overwrites before OnEnd reads (IsTracingReceipt is hardcoded true).

IDebugRpcModule.cs · JsonRpcMethod description — spec it: "post-tx state root, in execution order; excludes EIP-4788/2935 system calls and withdrawals; cost
scales O(txs × state_diff)." Geth's docs are bad - don't inherit that.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 9, 2026

Claude finished @svlachakis's task in 3m 51s —— View job


Review: additional findings on feat(rpc): add debug_intermediateRoots

  • Load task and understand request
  • Read relevant source files (GethStyleTracer.cs, IntermediateRootsBlockTracer.cs, DebugRpcModule.cs, IDebugRpcModule.cs, DebugModuleTests.cs)
  • Verify all raised findings against the code
  • Post inline comments and summary

All findings from the trigger comment are confirmed. Inline comments have been posted on each issue. Summary below.

High

1. Genesis NRE / wrong behaviourGethStyleTracer.cs:133

FindParent(block) returns null for genesis (the guard at line 240 skips the body entirely). That null is passed straight to BuildAndOverride, which may produce an NRE or silent wrong output. Geth explicitly rejects genesis with "genesis is not traceable". A guard is required before FindParent is called:

if (block.IsGenesis) throw new InvalidOperationException("genesis is not traceable");

2. InvalidOperationException catch too broadDebugRpcModule.cs:320–323

The catch block collapses three distinct conditions — block not found, invalid parent, and (post-fix) genesis not traceable — into a single ResourceNotFound response. The existing debug_standardTraceBlockToFile does not catch InvalidOperationException. Either remove the catch block to match that pattern, or introduce typed exceptions and map them individually.

Medium

3. No bad-block fallbackGethStyleTracer.cs:132

blockTree.FindBlock only searches canonical storage. Geth's debug_intermediateRoots (#23594) was designed for diagnosing divergence on bad blocks and checks rawdb.ReadBadBlock on miss. badBlockStore is already injected and used in TraceBadBlockToFile. Either wire it here or open a follow-up issue and add a // TODO: comment.

4. Partial-result semantics unspecified and untestedIntermediateRootsBlockTracer.cs:29–33

MarkAsFailed appends a root just like MarkAsSuccess, so the count of returned roots always equals the number of transactions. This is the correct semantic, but it is neither documented nor tested. Geth returns roots[0..i] on mid-block failure. Add at minimum one test with a block containing a failing tx and verify the root count and values.

5. Missing critical test scenariosDebugModuleTests.cs:456

Both existing tests mock IDebugBridge at the boundary. The following are unexercised: genesis → clean error, block-not-found → ResourceNotFound, N txs → N roots, system calls / withdrawals don't add entries, cancellation, and a reference-value correctness check. A TestBlockchain-based integration test is needed for the tracer path (at minimum: two-tx block, assert two distinct non-zero roots).

Low

6. Unused usingIntermediateRootsBlockTracer.cs:8

using Nethermind.Evm.TransactionProcessing; is not referenced. Remove.

7. Dead initializerIntermediateRootsBlockTracer.cs:27

StateRoot { get; private set; } = Keccak.Zero — since IsTracingReceipt is hardcoded true, MarkAsSuccess/MarkAsFailed always runs before OnEnd reads StateRoot. The initializer is never observable. Remove it to avoid confusing future readers.

8. Weak JsonRpcMethod descriptionIDebugRpcModule.cs:53

The current description says only "Replays the block already present in the database and returns the world state root after each transaction." Suggested addition: exclude EIP-4788/2935 system calls and withdrawals, note cost scaling, and (after bad-block wiring) state that canonical and bad-block stores are searched.


Verdict: not mergeable. Two High findings (genesis NRE, overly broad exception mapping) and three Medium findings (bad-block compat gap, partial-result semantics, test coverage) must be resolved or explicitly acknowledged with rationale before merge.

ArgumentNullException.ThrowIfNull(options);

Block block = blockTree.FindBlock(blockHash) ?? throw new InvalidOperationException($"Cannot find block {blockHash}");
BlockHeader? parent = FindParent(block);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — Genesis NRE / wrong behaviour

FindParent returns null when block.IsGenesis (see line 240: the body is skipped and null is returned). That null is then passed to BuildAndOverride on line 135. Whether this produces an NRE or silent wrong output depends on BuildAndOverride's implementation, but either way the behaviour is incorrect — geth explicitly rejects genesis with "genesis is not traceable".

Add a guard before the FindParent call:

Suggested change
BlockHeader? parent = FindParent(block);
Block block = blockTree.FindBlock(blockHash) ?? throw new InvalidOperationException($"Cannot find block {blockHash}");
if (block.IsGenesis) throw new InvalidOperationException("genesis is not traceable");
BlockHeader? parent = FindParent(block);

Fix this →

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch. Added an explicit block.IsGenesis guard with the same wording geth uses, before FindParent runs. a160e9f1.

Comment on lines +320 to +323
catch (InvalidOperationException ex)
{
return ResultWrapper<IReadOnlyCollection<Hash256>>.Fail(ex.Message, ErrorCodes.ResourceNotFound);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — Overly broad exception mapping

InvalidOperationException is thrown for at least three distinct conditions: "cannot find block" (missing from tree), "cannot trace blocks with invalid parents" (bad chain state), and — once the genesis guard is added — "genesis is not traceable". All three land on ErrorCodes.ResourceNotFound, which is wrong for the latter two.

Compare to the existing debug_standardTraceBlockToFile, which does not catch InvalidOperationException — the RPC framework maps unhandled exceptions to an internal error, which is at least not misleading. Either remove the catch block entirely (matching that pattern), or introduce typed exceptions (BlockNotFoundException, TraceNotSupportedException) and map them individually.

Fix this →

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it was too coarse — dropped the catch in 4b194fbe. Block-not-found is already covered by TryGetHeaderAndCheckState upstream, and the other paths (bad parent, genesis) shouldn't be masquerading as ResourceNotFound.

ArgumentNullException.ThrowIfNull(blockHash);
ArgumentNullException.ThrowIfNull(options);

Block block = blockTree.FindBlock(blockHash) ?? throw new InvalidOperationException($"Cannot find block {blockHash}");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — No bad-block fallback (compat gap vs geth)

blockTree.FindBlock(blockHash) only searches canonical storage. Geth's debug_intermediateRoots (ethereum/go-ethereum#23594) was specifically designed for diagnosing client divergence on bad blocks — blocks that were received and rejected — so it also checks rawdb.ReadBadBlock.

GethStyleTracer already has badBlockStore injected and used in TraceBadBlockToFile. This path should either:

  1. Also check badBlockStore.GetAll() on lookup miss, or
  2. Open a follow-up issue and add a // TODO: comment here so the gap is tracked explicitly.

Fix this →

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in a160e9f1 — canonical lookup first, then badBlockStore.GetAll().FirstOrDefault(...) before throwing, same shape as TraceBadBlockToFile.

Comment on lines +29 to +33
public override void MarkAsSuccess(Address recipient, in GasConsumed gasSpent, byte[] output, LogEntry[] logs, Hash256? stateRoot = null) =>
Capture(stateRoot);

public override void MarkAsFailed(Address recipient, in GasConsumed gasSpent, byte[] output, string? error, Hash256? stateRoot = null) =>
Capture(stateRoot);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Partial-result semantics unspecified and untested

If a transaction fails mid-block (e.g. an OOG reverted tx), MarkAsFailed is still called and a root is still appended — so the count of returned roots always equals the number of transactions regardless of success/failure. This is the correct and intended semantics, but:

  1. It is not documented: the JsonRpcMethod description and XML docs don't mention whether failed txs contribute a root.
  2. It is not tested: there's no test with a mixed success/failure block that verifies the final count and the state after a failed tx.

Geth returns roots[0..i] with nil error when a tx fails mid-block (the exact diagnostic use case). Add a test asserting that a block with one successful tx and one OOG tx returns exactly two roots, and the second root equals the state root after the failed tx's gas refund.

Fix this →

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented the behaviour in the tracer's XML <remarks> (5d39af79) and the JsonRpcMethod description (598cf9b1). Didn't add a dedicated failed-tx test in this round — wants an OOG fixture wired up. Can layer it in here if you'd rather not defer, otherwise will track separately.

using Nethermind.Core.Crypto;
using Nethermind.Evm.State;
using Nethermind.Evm.Tracing;
using Nethermind.Evm.TransactionProcessing;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — Unused using

Nethermind.Evm.TransactionProcessing is not referenced anywhere in this file. Remove it.

Suggested change
using Nethermind.Evm.TransactionProcessing;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left this one in — GasConsumed lives in Nethermind.Evm.TransactionProcessing and it's the type in the MarkAsSuccess/MarkAsFailed signatures. Tried removing it and the build went red.

{
public override bool IsTracingReceipt => true;

public Hash256 StateRoot { get; private set; } = Keccak.Zero;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — Dead initializer

IsTracingReceipt is hardcoded true, so MarkAsSuccess or MarkAsFailed is guaranteed to run before OnEnd reads StateRoot. The = Keccak.Zero initializer is never observable and misleads readers into thinking a tx could exit with Keccak.Zero (it never can). Remove it or replace with just the auto-property declaration.

Suggested change
public Hash256 StateRoot { get; private set; } = Keccak.Zero;
public Hash256 StateRoot { get; private set; }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gone in 5d39af79.

[JsonRpcMethod(Description = "Similar to debug_traceBlock, this method accepts a block hash and replays the block that is already present in the database.", IsImplemented = true, IsSharable = false)]
ResultWrapper<IReadOnlyCollection<GethLikeTxTrace>> debug_traceBlockByHash(Hash256 blockHash, GethTraceOptions options = null);

[JsonRpcMethod(Description = "Replays the block already present in the database and returns the world state root after each transaction.", IsImplemented = true, IsSharable = false)]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — Weak JsonRpcMethod description

The current description omits details that callers need to understand the contract. Geth's docs are equally sparse. Suggested richer description:

Suggested change
[JsonRpcMethod(Description = "Replays the block already present in the database and returns the world state root after each transaction.", IsImplemented = true, IsSharable = false)]
[JsonRpcMethod(Description = "Replays a block and returns the world-state root after each transaction in execution order. EIP-4788/EIP-2935 system calls and withdrawals do not produce an entry. Cost scales O(txs × state_diff). Block must be canonical or in the bad-block store.", IsImplemented = true, IsSharable = false)]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrote it in 598cf9b1 to spell out the contract: replay semantics, system-call/withdrawal exclusion, partial-result behaviour, and the canonical-or-bad-block lookup.


actual.Result.ResultType.Should().Be(ResultType.Failure);
actual.ErrorCode.Should().Be(ErrorCodes.ResourceUnavailable);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Critical test scenarios missing

Both current tests mock IDebugBridge at the boundary — the "matches geth" claim is unverified. The following scenarios are completely uncovered:

Scenario Why it matters
N txs in → exactly N roots out Basic contract verification
Genesis hash → clean error (not NRE) Once genesis guard is added
Block-not-found → ResourceNotFound Exercises FindHeader null path
Failed tx still produces a root Partial-result semantics
System calls (EIP-4788/2935) don't add entry Spec correctness
Withdrawals don't add entry Spec correctness
Cancellation → correct error Parity with other trace methods
Root equals hand-computed post-tx stateRoot Correctness against known reference

At minimum, add a TestBlockchain-based integration test (similar to those in GethStyleTracerTests) that replays a two-transaction block and asserts the two returned roots are distinct and non-zero. The block-not-found and genesis error tests can stay at the DebugRpcModule unit level.

Fix this →

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Integration test in 74b08eb2 covers count, distinctness, non-zero, plus the genesis rejection. Unit-level block-not-found in 8258b4fa. The remaining four (system-call/withdrawal absence, failed-tx still producing a root, hand-computed reference, cancellation) need richer fixtures — would rather not bolt them on hastily. Happy to either pull them into this PR or track in a follow-up; let me know which you prefer.

…Roots

Match geth: reject genesis with a clear error and fall back to the bad-block
store when the canonical lookup misses, so the diagnostic use case (replaying
a rejected block) works. Wrap processing in try/catch+TryDispose to mirror
the other tracer entry points.
Drop the unobservable Keccak.Zero initializer (IsTracingReceipt is hardcoded
true, so MarkAsSuccess/MarkAsFailed always runs before OnEnd reads StateRoot)
and document partial-result behaviour: failed transactions still produce a
root, system calls (EIP-4788, EIP-2935) and withdrawals do not.
…termediateRoots

The catch mapped every InvalidOperationException to ResourceNotFound, which
is wrong for non-existence errors like "genesis is not traceable" or
"cannot trace blocks with invalid parents". Match the debug_standardTraceBlockToFile
pattern and let the framework map unhandled exceptions to an internal error
instead of misclassifying them.
Spell out the contract on the public API: post-tx roots in execution order,
system calls and withdrawals excluded, failed txs still produce a root,
canonical or bad-block lookup.
Mirrors StandardTraceBlockToFile_returns_error_when_missing_block: when the
block header lookup misses, the RPC must return ResourceNotFound rather than
relying on the bridge to throw.
TransactionProcessor commits with commitRoots=false on EIP-658 chains, which
buffers account/storage changes into _blockChanges without flushing them to
the trie. RecalculateStateRoot then re-derives the root from an unchanged
trie, yielding the parent block's root for every transaction.

Plumb the active IReleaseSpec into IntermediateRootsBlockTracer and force a
commitRoots=true flush before reading the root. The BlockProcessor's later
end-of-block commit is idempotent for already-flushed entries (Before=After
short-circuit in FlushToTree).
Replay a real two-transaction block through TestRpcBlockchain and assert the
returned roots: one per transaction, all non-zero, all distinct. Also assert
that calling the RPC on the genesis hash surfaces the "genesis is not
traceable" error from GethStyleTracer rather than returning a misleading
result.
@0xDevNinja
Copy link
Copy Markdown
Author

Pushed 7 commits addressing the review.

  • Genesis guard, bad-block fallback, try/catch hygiene → a160e9f1
  • Dropped the over-broad InvalidOperationException catch → 4b194fbe
  • Tracer cleanup + <remarks> documenting partial-result semantics → 5d39af79
  • Interface XML doc + richer JsonRpcMethod description → 598cf9b1
  • Block-not-found unit test → 8258b4fa
  • Integration test (2-tx block, distinct non-zero roots + genesis rejection) → 74b08eb2

One thing worth flagging separately: while writing the integration test I noticed the post-Byzantium fallback was returning the parent block's root for every transaction. TransactionProcessor commits each tx with commitRoots=false, which buffers account changes into _blockChanges without flushing them to the trie, so UpdateRootHash was re-deriving the same unchanged root. Plumbed IReleaseSpec into the tracer and force a commitRoots=true flush per tx — fix in b3290a83. The end-of-block commit stays idempotent because FlushToTree short-circuits when Before == After.

Two coverage items from the critical-scenarios thread that I didn't ship: a failed-tx-still-produces-a-root assertion and a hand-computed reference root. Both want richer fixtures — happy to pull them into this PR or track in a follow-up, whichever you prefer.

@LukaszRozmej
Copy link
Copy Markdown
Member

@claude re-review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 11, 2026

Claude finished @LukaszRozmej's task in 4m 49s —— View job


Re-review: feat(rpc): add debug_intermediateRoots

  • Load rules and gather context
  • Read current state of all changed files
  • Verify each previous finding is addressed
  • Check new commits for regressions or new issues
  • Post final verdict

What was fixed

All 2 High and most Medium/Low findings from the previous reviews are resolved:

Finding Resolution
H Genesis NRE Guard added before FindParent — throws InvalidOperationException("genesis is not traceable")
H Over-broad InvalidOperationException catch Dropped entirely in 4b194fb; block-not-found already handled upstream by TryGetHeaderAndCheckState
M No bad-block fallback Added in a160e9f1 — canonical first, then badBlockStore.GetAll().FirstOrDefault(...)
M Partial-result semantics undocumented <remarks> on tracer class + JsonRpcMethod description both updated
L Missing try/catch + TryDispose() Added in a160e9f1
L Missing XML doc on IGethStyleTracer Added in 598cf9b1
L Weak JsonRpcMethod description Replaced with spec-accurate description in 598cf9b1
L Dead = Keccak.Zero initializer Removed in 5d39af79
L "Unused" using Nethermind.Evm.TransactionProcessing Developer correctly kept it — GasConsumed (used in method overrides) is in that namespace
Bug Post-Byzantium roots all equal parent root Fixed in b3290a83: IReleaseSpec injected into tracer; worldState.Commit(spec, commitRoots: true) called per-tx to flush before RecalculateStateRoot
Test Block-not-found unit test Added in 8258b4fa
Test Integration test 74b08eb2 — two-tx block via TestRpcBlockchain, asserts two distinct non-zero roots + genesis rejection

Remaining findings

Medium

Genesis throws unhandled exception at the RPC levelDebugRpcModule.cs:301

TryGetHeaderAndCheckState passes for genesis (genesis header is found; genesis state is available). The InvalidOperationException("genesis is not traceable") thrown inside GethStyleTracer propagates through DebugBridge.GetBlockIntermediateRoots with no handler in debug_intermediateRoots. In a live node, RPC callers receive a generic internal-error response (−32603) instead of a client-meaningful one. The integration test explicitly tests that the exception is thrown (act.Should().Throw<InvalidOperationException>()), so this is intentional — but it is inconsistent with how the same method handles block-not-found (→ ResourceNotFound) and state-unavailable (→ ResourceUnavailable).

debug_traceBlockByHash has the same gap for genesis (it would throw internally too), so this is pattern-consistent — but the genesis guard here is an explicit, known rejection case, which makes it straightforward to map:

try
{
    IReadOnlyCollection<Hash256> roots = debugBridge.GetBlockIntermediateRoots(blockHash, cancellationToken, options);
    if (_logger.IsTrace) _logger.Trace($"...");
    return ResultWrapper<IReadOnlyCollection<Hash256>>.Success(roots);
}
catch (InvalidOperationException e) when (e.Message.Contains("genesis"))
{
    return ResultWrapper<IReadOnlyCollection<Hash256>>.Fail(e.Message, ErrorCodes.InvalidInput);
}

Or better: introduce a GenesisNotTraceableException in the tracer and catch that specifically.

Fix this →

Medium (explicitly acknowledged, deferred)

Test coverage gapsDebugRpcModuleTests.IntermediateRoots.cs

Developer explicitly acknowledged these are not in this PR and offered to add or defer:

  • Failed tx still produces a root (partial-result semantics test)
  • System-call/withdrawal exclusion verified at the integration level
  • Hand-computed reference root vs known-good state
  • Cancellation produces correct error

These are documented as a known gap — not blocking if reviewers accept the deferral.

Low

LINQ in bad-block lookupGethStyleTracer.cs:135

badBlockStore.GetAll().FirstOrDefault(b => b.Hash == blockHash) uses LINQ. The no-LINQ style rule applies, but this is identical to the existing pattern in TraceBadBlockToFile in the same file. The inconsistency is pre-existing; a nit at most.


What looks good

  • Post-Byzantium root fix (b3290a83) is non-trivial and correct: calling worldState.Commit(spec, commitRoots: true) mid-block (after MarkAsSuccess/MarkAsFailed, when the tx is fully committed) flushes the journal to the trie so RecalculateStateRoot sees this transaction's effect. The end-of-block commit is idempotent. The inline comment explains the invariant clearly.
  • Bad-block fallback mirrors TraceBadBlockToFile exactly — consistency is good.
  • Genesis guard fires before FindParent, eliminating the NRE.
  • Interface XML doc is precise and spec-faithful (partial-result semantics, exclusions, canonical-or-bad-block lookup all documented).
  • Test structure — integration tests live in a new partial class file following the established DebugRpcModuleTests.*.cs convention; CreateTraceBlockTransactions is reused from the trace-block suite.

Verdict: not mergeable — one new Medium finding (genesis exception propagates as unhandled instead of a clean error response) that was not present or acknowledged in prior comments.


if (_logger.IsTrace) _logger.Trace($"{nameof(debug_intermediateRoots)} request {blockHash}, roots: {roots.Count}");

return ResultWrapper<IReadOnlyCollection<Hash256>>.Success(roots);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Genesis throws unhandled exception

TryGetHeaderAndCheckState passes for genesis (the header exists and state is available), so the InvalidOperationException("genesis is not traceable") thrown by GethStyleTracer propagates unhandled to the RPC framework — clients get a −32603 internal error instead of a meaningful response. All other invalid-input conditions on this method return clean ResourceNotFound / ResourceUnavailable wrappers.

Suggested change
return ResultWrapper<IReadOnlyCollection<Hash256>>.Success(roots);
IReadOnlyCollection<Hash256> roots;
try
{
roots = debugBridge.GetBlockIntermediateRoots(blockHash, cancellationToken, options);
}
catch (InvalidOperationException e)
{
return ResultWrapper<IReadOnlyCollection<Hash256>>.Fail(e.Message, ErrorCodes.InvalidInput);
}

(Or introduce a typed GenesisNotTraceableException and catch that specifically to avoid catching unrelated InvalidOperationExceptions from deeper in the stack.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add debug.intermediateRoots

3 participants